I recently wrote about building desktop applications with Couchbase Lite and JavaFX. As demonstrated Couchbase makes an excellent solution for data storage and sync in a desktop application because of the Java SDK available. However, I realize JavaFX is not for everyone.
There is another, similar framework for building desktop applications in Java. It is called Gluon, and it offers support for Android and iOS applications as well. However, we’re strictly looking at desktop in this example.
We’re going to see how to create a Gluon desktop application using nearly the same code that was found in our previous JavaFX example.
The Requirements
There are a few requirements towards building a Gluon application that uses Couchbase.
- JDK 1.7+
- IntelliJ IDEA
- Couchbase Sync Gateway
I don’t typically make this a requirement, but it is far easier to create a Gluon application with an IDE like IntelliJ, thus why it is in the list. There is a plugin for IntelliJ that will construct a Gluon project with Gradle and everything you need.
While Couchbase Sync Gateway isn’t truly a requirement, it is necessary if you want to add synchronization support between your application and Couchbase Server / other platforms and devices.
Creating a New Gluon Project
If you decide to use IntelliJ to build your project, make sure you’ve already downloaded the Gluon plugin as described here.
Using IntelliJ, create a new project, but choose to create a Gluon Desktop – Multiple View Project with FXML project as seen below.
Ultimately, it is up to you where to go from here, but to stay as close as possible to this guide, give your project a com.couchbaselabs package name and gluon main class.
Everything that follows can be left as the default as we’re only going to make a two page application with Gluon. When we’re done, hopefully we’re left with a file and directory structure that looks like the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
gradle wrapper src main java com couchbaselabs views PrimaryPresenter.java PrimaryView.java SecondaryPresenter.java SecondaryView.java CouchbaseSingleton.java Todo.java gluon.java resources couchbaselabs views primary.fxml primary.css secondary.fxml secondary.css style.css icon.png build.gradle gradlew gradlew.bat |
You’ll notice that I did create a few extra files in there such as CouchbaseSingleton.java and Todo.java.
Essentially we have XML views and controllers to go with those views. This is very similar to what we saw in a JavaFX application. When it comes to designing those views, we have a few options. We can use raw XML, or we can use SceneBuilder. Now this SceneBuilder is not to be confused with JavaFX SceneBuilder. I made this mistake and was banging my head for quite a bit of time. The version we want will support Gluon applications.
Before we start adding application code, we should add our dependencies to the project Gradle file. If you’re unfamiliar with Gradle, it does the same job as Maven or Ant. The syntax is different, but I personally find it a little cleaner. Open the project’s build.gradle and include the following code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
buildscript { repositories { jcenter() } dependencies { classpath 'org.javafxports:jfxmobile-plugin:1.0.8' } } apply plugin: 'org.javafxports.jfxmobile' repositories { jcenter() maven { url 'http://nexus.gluonhq.com/nexus/content/repositories/releases' } } mainClassName = 'com.couchbaselabs.gluon' dependencies { compile 'com.gluonhq:charm:3.0.0' compile 'com.couchbase.lite:couchbase-lite-java:1.3.0' desktopRuntime 'com.gluonhq:charm-desktop:3.0.0' } |
What’s particularly important here are the dependencies:
1 2 3 4 5 6 7 |
dependencies { compile 'com.gluonhq:charm:3.0.0' compile 'com.couchbase.lite:couchbase-lite-java:1.3.0' desktopRuntime 'com.gluonhq:charm-desktop:3.0.0' } |
This will include the Couchbase Lite library as well as the desktop application runtime for Gluon.
With the project ready to go, we can start developing the application.
Designing the Couchbase Data Layer
When working with Couchbase it is a good idea to create a singleton instance of it. This means we’re going to use the same open instance throughout the entire application, until we decide to close it.
Open the project’s src/main/java/com/couchbaselabs/CouchbaseSingleton.java file and include the following code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 |
package com.couchbaselabs; import com.couchbase.lite.*; import com.couchbase.lite.replicator.Replication; import java.net.URL; import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.Map; public class CouchbaseSingleton { private Manager manager; private Database database; private Replication pushReplication; private Replication pullReplication; private static CouchbaseSingleton instance = null; private CouchbaseSingleton() { try { this.manager = new Manager(new JavaContext("data"), Manager.DEFAULT_OPTIONS); this.database = this.manager.getDatabase("fx-project"); View todoView = database.getView("todos"); todoView.setMap(new Mapper() { @Override public void map(Map<String, Object> document, Emitter emitter) { emitter.emit(document.get("_id"), document); } }, "1"); } catch (Exception e) { e.printStackTrace(); } } public static CouchbaseSingleton getInstance() { if(instance == null) { instance = new CouchbaseSingleton(); } return instance; } public Database getDatabase() { return this.database; } public void startReplication(URL gateway, boolean continuous) { this.pushReplication = this.database.createPushReplication(gateway); this.pullReplication = this.database.createPullReplication(gateway); this.pushReplication.setContinuous(continuous); this.pullReplication.setContinuous(continuous); this.pushReplication.start(); this.pullReplication.start(); } public void stopReplication() { this.pushReplication.stop(); this.pullReplication.stop(); } public Todo save(Todo todo) { Map<String, Object> properties = new HashMap<String, Object>(); Document document = this.database.createDocument(); properties.put("type", "todo"); properties.put("title", todo.getTitle()); properties.put("description", todo.getDescription()); try { todo.setDocumentId(document.putProperties(properties).getDocument().getId()); } catch (Exception e) { e.printStackTrace(); } return todo; } public ArrayList query() { ArrayList results = new ArrayList(); try { View todoView = this.database.getView("todos"); Query query = todoView.createQuery(); QueryEnumerator result = query.run(); Document document = null; for (Iterator it = result; it.hasNext(); ) { QueryRow row = it.next(); document = row.getDocument(); results.add(new Todo(document.getId(), (String) document.getProperty("title"), (String) document.getProperty("description"))); } } catch (Exception e) { e.printStackTrace(); } return results; } } |
If you saw the JavaFX application I built previously, you’ll notice that this singleton is the same between the two projects. You can even use a similar version for Android.
In the CouchbaseSingleton
constructor method we are creating and opening a local database called fx-project. This database will be used throughout the application. We are also creating our Couchbase Lite view for querying. This todos
view will emit a key-value pair of document id and document for every document in the local database.
The constructor method is private, meaning we don’t want the user to be able to instantiate an object from it. Instead we want to use a static getInstance
method to get the job done.
While we won’t worry about replication until later in the guide, we do want to lay the foundation. The startReplication
method will allow us to define bi-directional sync with a Sync Gateway and the stopReplication
method will allow us to stop replication, maybe when the application closes.
Now we have our functions for saving and loading data.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public Todo save(Todo todo) { Map<String, Object> properties = new HashMap<String, Object>(); Document document = this.database.createDocument(); properties.put("type", "todo"); properties.put("title", todo.getTitle()); properties.put("description", todo.getDescription()); try { todo.setDocumentId(document.putProperties(properties).getDocument().getId()); } catch (Exception e) { e.printStackTrace(); } return todo; } |
In the save
method we are accepting a custom Todo
object. This object really just contains an id, a title, and a description. The class looks something like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
package com.couchbaselabs; import java.util.*; public class Todo { private String documentId; private String title; private String description; public Todo(String documentId, String title, String description) { this.documentId = documentId; this.title = title; this.description = description; } public Todo(String title, String description) { this.documentId = UUID.randomUUID().toString(); this.title = title; this.description = description; } public void setDocumentId(String documentId) { this.documentId = documentId; } public String getDocumentId() { return this.documentId; } public String getTitle() { return this.title; } public String getDescription() { return this.description; } } |
The above class is found in the src/main/java/com/couchbaselabs/Todo.java file. What we’re doing is actually taking the object and adding it as properties to a Couchbase NoSQL document. After we save the document and obtain an id, we return the same document with the id included.
The query function will execute the view that we created earlier and add each of the result items to an array of Todo
object, bringing our database singleton to an end.
Creating a View for Listing Data
We are going to be creating an application that uses multiple Gluon views instead of trying to slap everything into the same view. This is not to be confused with Couchbase Lite Views which are on the topic of data, not UI.
The default view will be the first view that comes up when we launch the application. This view will show a list of all our todo elements. If not using SceneBuilder, the XML markup found in src/main/resources/com/couchbaselabs/views/primary.fxml would look like the following:
1 2 3 4 5 6 |
<!--?xml version="1.0" encoding="UTF-8"?--> <!--?import com.gluonhq.charm.glisten.mvc.View?--> <!--?import javafx.scene.control.ListView?--> <!--?import javafx.scene.layout.BorderPane?--> |
1 2 3 |
The view that comes out of this will look like the following:
You’ll see in the image that there is a navigation bar with a button, but it doesn’t appear in the XML layout. The layout instead only contains the list view. However, the XML does reference our src/main/java/com/couchbaselabs/views/PrimaryPresenter.java file. This is the file where we not only define the navigation bar, but any logic that powers the particular view.
The src/main/java/com/couchbaselabs/views/PrimaryPresenter.java file will hold a lot of resemblence to our JavaFX project, with the differences being in the navigation component.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
package com.couchbaselabs.views; import com.couchbaselabs.CouchbaseSingleton; import com.couchbaselabs.Todo; import com.couchbase.lite.Database; import com.couchbase.lite.Document; import com.couchbaselabs.gluon; import com.gluonhq.charm.glisten.application.MobileApplication; import com.gluonhq.charm.glisten.control.AppBar; import com.gluonhq.charm.glisten.mvc.View; import com.gluonhq.charm.glisten.visual.MaterialDesignIcon; import javafx.application.Platform; import javafx.collections.ObservableList; import javafx.fxml.FXML; import javafx.scene.control.ListCell; import javafx.scene.control.ListView; import javafx.util.Callback; public class PrimaryPresenter { private CouchbaseSingleton couchbase; @FXML private View primary; @FXML private ListView fxListView; public void initialize() { try { this.couchbase = CouchbaseSingleton.getInstance(); fxListView.getItems().addAll(this.couchbase.query()); this.couchbase.getDatabase().addChangeListener(new Database.ChangeListener() { @Override public void changed(Database.ChangeEvent event) { for(int i = 0; i < event.getChanges().size(); i++) { final Document retrievedDocument = couchbase.getDatabase().getDocument(event.getChanges().get(i).getDocumentId()); Platform.runLater(new Runnable() { @Override public void run() { int documentIndex = indexOfByDocumentId(retrievedDocument.getId(), fxListView.getItems()); for (int j = 0; j < fxListView.getItems().size(); j++) { if (((Todo) fxListView.getItems().get(j)).getDocumentId().equals(retrievedDocument.getId())) { documentIndex = j; break; } } if (retrievedDocument.isDeleted()) { if (documentIndex > -1) { fxListView.getItems().remove(documentIndex); } } else { if (documentIndex == -1) { fxListView.getItems().add(new Todo(retrievedDocument.getId(), (String) retrievedDocument.getProperty("title"), (String) retrievedDocument.getProperty("description"))); } else { fxListView.getItems().remove(documentIndex); fxListView.getItems().add(new Todo(retrievedDocument.getId(), (String) retrievedDocument.getProperty("title"), (String) retrievedDocument.getProperty("description"))); } } } }); } } }); } catch (Exception e) { e.printStackTrace(); } fxListView.setCellFactory(new Callback<ListView, ListCell>() { @Override public ListCell call(ListView p) { ListCell cell = new ListCell() { @Override protected void updateItem(Todo t, boolean bln) { super.updateItem(t, bln); if (t != null) { setText(t.getTitle()); } } }; return cell; } }); primary.showingProperty().addListener((obs, oldValue, newValue) -> { if (newValue) { AppBar appBar = MobileApplication.getInstance().getAppBar(); appBar.setTitleText("Couchbase Todo - List"); appBar.getActionItems().add(MaterialDesignIcon.ADD.button(e -> MobileApplication.getInstance().switchView(gluon.SECONDARY_VIEW) )); } }); } private int indexOfByDocumentId(String needle, ObservableList haystack) { int result = -1; for(int i = 0; i < haystack.size(); i++) { if(haystack.get(i).getDocumentId().equals(needle)) { result = i; break; } } return result; } } |
In the above file we have the list view property bound to the actual list view in the XML. The code that really matters, however, is the code found in the initialize
method. In it we do three core things.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
fxListView.setCellFactory(new Callback<ListView, ListCell>() { @Override public ListCell call(ListView p) { ListCell cell = new ListCell() { @Override protected void updateItem(Todo t, boolean bln) { super.updateItem(t, bln); if (t != null) { setText(t.getTitle()); } } }; return cell; } }); |
In the above code we define how the data will appear in the list. By default it only accepts string data, so we override it to take the title from our Todo
objects.
1 2 3 4 5 6 7 8 9 10 11 |
primary.showingProperty().addListener((obs, oldValue, newValue) -> { if (newValue) { AppBar appBar = MobileApplication.getInstance().getAppBar(); appBar.setTitleText("Couchbase Todo - List"); appBar.getActionItems().add(MaterialDesignIcon.ADD.button(e -> MobileApplication.getInstance().switchView(gluon.SECONDARY_VIEW) )); } }); |
In the above listener we set the title of our navigation bar as well as the button. When the button is pressed, the view will change to our secondary view.
Finally it leaves us with running the initial data query and populating the list, as well as listening for new data as it comes in. Should changes come in, they will be iterated over and the indicators will be reviewed on every changed document. If there was a delete indictator, the data will be removed from the list view. If there was a change, the data from the list view will be removed, then replaced. Otherwise the data will only be added. Since the listener operates on a background thread, changes to the UI must be done within the Platform.runLater
.
This brings us to the second and final view.
Creating a View for Saving Data
The second view will have a form and is responsible for user input to be added to the database and displayed in the previous view. The XML markup that powers this view will look like the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 |
<!--?xml version="1.0" encoding="UTF-8"?--> <!--?import com.gluonhq.charm.glisten.mvc.View?--> <!--?import javafx.geometry.Insets?--> <!--?import javafx.scene.control.TextArea?--> <!--?import javafx.scene.control.TextField?--> <!--?import javafx.scene.layout.BorderPane?--> <!--?import javafx.scene.layout.VBox?--> <textarea> </children> </VBox> </top> <padding> <Insets bottom="10.0" left="10.0" right="10.0" top="10.0" /> </padding> </View> </code> </pre> <p>The XML above is found in the <strong>src/main/resources/com/couchbaselabs/views/secondary.fxml</strong> file and the view itself looks like the following:</p> <p><img src="/wp-content/original-assets/2016/october/using-couchbase-lite-in-a-java-gluon-application/cb-gluon-desktop-4.png" /></p> <p>Notice that there are two <code>TextField</code> inputs. They will be important in the <strong>src/main/java/com/couchbaselabs/views/SecondaryPresenter.java</strong> file referenced in the XML. This file contains the following code:</p> <pre> <code> package com.couchbaselabs.views; import com.couchbaselabs.CouchbaseSingleton; import com.couchbaselabs.Todo; import com.gluonhq.charm.glisten.animation.BounceInRightTransition; import com.gluonhq.charm.glisten.application.MobileApplication; import com.gluonhq.charm.glisten.control.AppBar; import com.gluonhq.charm.glisten.layout.layer.FloatingActionButton; import com.gluonhq.charm.glisten.mvc.View; import com.gluonhq.charm.glisten.visual.MaterialDesignIcon; import javafx.fxml.FXML; import javafx.scene.control.Alert; import javafx.scene.control.TextArea; import javafx.scene.control.TextField; public class SecondaryPresenter { private CouchbaseSingleton couchbase; @FXML private View secondary; @FXML private TextField fxTitle; @FXML private TextArea fxDescription; public void initialize() { this.couchbase = CouchbaseSingleton.getInstance(); secondary.setShowTransitionFactory(BounceInRightTransition::new); secondary.getLayers().add(new FloatingActionButton(MaterialDesignIcon.SAVE.text, e -> save() )); secondary.showingProperty().addListener((obs, oldValue, newValue) -> { if (newValue) { AppBar appBar = MobileApplication.getInstance().getAppBar(); appBar.setTitleText("Couchbase Todo - Create"); } }); } private void save() { if(!fxTitle.getText().equals("") && !fxDescription.getText().equals("")) { couchbase.save(new Todo(fxTitle.getText(), fxDescription.getText())); fxTitle.setText(""); fxDescription.setText(""); MobileApplication.getInstance().switchToPreviousView(); } else { Alert alert = new Alert(Alert.AlertType.INFORMATION); alert.setTitle("Missing Information"); alert.setHeaderText(null); alert.setContentText("Both a title and description are required for this example."); alert.showAndWait(); } } } </code> </pre> <p>The input fields are mapped to this controller, but what really matters here is the code for adding the floating action button and setting the navigation bar title.</p> <pre> <code> secondary.getLayers().add(new FloatingActionButton(MaterialDesignIcon.SAVE.text, e -> save() )); secondary.showingProperty().addListener((obs, oldValue, newValue) -> { if (newValue) { AppBar appBar = MobileApplication.getInstance().getAppBar(); appBar.setTitleText("Couchbase Todo - Create"); } }); </code> </pre> <p>When the floating action button is clicked, the <code>save</code> method is called. In the <code>save</code> method we check to make sure the input fields are not blank and if they aren't, save the data and navigate backwards in the stack to the previous view.</p> <h2>Syncing Data with Couchbase Sync Gateway</h2> <p>Up until now, every part of our Gluon application was built for offline local use. However, including synchronization support into the mix is not only useful, but incredibly easy.</p> <p>At this point I'm going to assume you've downloaded and installed Couchbase Sync Gateway. Before we run it, we need to create a configuration file. Create a JSON file with the following:</p> <pre> <code> { "log":["CRUD+", "REST+", "Changes+", "Attach+"], "databases": { "fx-example": { "server":"walrus:", "sync":` function (doc) { channel (doc.channels); } `, "users": { "GUEST": { "disabled": false, "admin_channels": ["*"] } } } } } </code> </pre> <p>The above configuration file is one of the most simplest you can make for Sync Gateway. You're creating a partition called <strong>fx-example</strong> within the in-memory database <strong>walrus</strong> and you're accepting all documents from everyone with no read or write permissions.</p> <p>Running this configuration with Sync Gateway won't get us very far yet because we haven't activated sync support in our application. Open the project's <strong>src/main/java/com/couchbaselabs/gluon.java</strong> file and include the following:</p> <pre> <code> package com.couchbaselabs; import com.couchbaselabs.views.PrimaryView; import com.couchbaselabs.views.SecondaryView; import com.gluonhq.charm.glisten.application.MobileApplication; import com.gluonhq.charm.glisten.visual.Swatch; import javafx.scene.Scene; public class gluon extends MobileApplication { public static final String PRIMARY_VIEW = HOME_VIEW; public static final String SECONDARY_VIEW = "Secondary View"; public CouchbaseSingleton couchbase; @Override public void init() { addViewFactory(PRIMARY_VIEW, () -> new PrimaryView(PRIMARY_VIEW).getView()); addViewFactory(SECONDARY_VIEW, () -> new SecondaryView(SECONDARY_VIEW).getView()); } @Override public void postInit(Scene scene) { Swatch.BLUE.assignTo(scene); scene.getStylesheets().add(gluon.class.getResource("style.css").toExternalForm()); try { this.couchbase = CouchbaseSingleton.getInstance(); this.couchbase.startReplication(new URL("http://localhost:4984/fx-example/"), true); } catch (Exception e) { e.printStackTrace(); } } } </code> </pre> <p>Really we only care about the <code>startReplication</code> line in the <code>postInit</code> method. Once we call it, replication will happen in both directions, continuously.</p> <h2>Conclusion</h2> <p>You just saw how to create a Java desktop application with Gluon and Couchbase. Using Gradle you can build and run the application and with a few revisions it can be converted to Android as well.</p> <p>The full source code to this project can be found on GitHub <a href="https://github.com/couchbaselabs/couchbase-lite-gluon-example">here</a>.</p> </textarea> |